Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
148 changes: 148 additions & 0 deletions .github/workflows/deploy-site-task.yml
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
name: Deploy site task

on:
workflow_call:
inputs:
environment:
description: The GitHub Environment to deploy to, production or staging.
required: true
type: string

env:
# Pinned by version and checksum, because the site is reproducible only if the generator is.
# Update both values together.
HUGO_VERSION: 0.164.0
HUGO_SHA256: 8325f3653032d0fc536503691f4833dc4eb6c6be02ee62466758f3f37a7f2fcd

jobs:

# The name selects a GitHub Environment and lands in a remote path, and a workflow_call caller
# is not bound by the dispatch choice list.
# A separate job, because the environment binding below resolves before any step runs.
assert-environment:
name: Assert environment name job
runs-on: ubuntu-latest
steps:
- name: Assert environment is known step
env:
ENVIRONMENT: ${{ inputs.environment }}
run: |
set -Eeuo pipefail
case "$ENVIRONMENT" in
production | staging) ;;
*)
echo "::error::environment must be production or staging; got '$ENVIRONMENT'."
exit 1
;;
esac

# Host-specific values come from the environment, so this file names no host, path, or address.
deploy:
name: Deploy site job
runs-on: ubuntu-latest
needs: [ assert-environment ]
environment: ${{ inputs.environment }}
permissions:
contents: read

steps:

# Full history, because a shallow clone silently changes page metadata if git info is on.
- name: Checkout code step
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0

- name: Install Hugo step
run: |
set -Eeuo pipefail
deb="hugo_extended_${HUGO_VERSION}_linux-amd64.deb"
curl -sSLf -o "$deb" \
"https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/${deb}"
echo "${HUGO_SHA256} ${deb}" | sha256sum --check --strict
sudo dpkg --install "$deb"
hugo version

# REQUIRE_BROTLI below makes a missing binary fatal, so this keeps the build from failing.
- name: Install brotli step
run: |
set -Eeuo pipefail
sudo apt-get update
sudo apt-get install --yes --no-install-recommends brotli

# Derived once and used three times, as the directory name, the stamp, and EXPECT_RELEASE.
# Deriving it twice yields ids seconds apart, and the gate then asserts a phantom version.
- name: Resolve release id step
id: release
run: |
set -Eeuo pipefail
echo "id=$(date -u +%Y%m%d-%H%M%S)" >> "$GITHUB_OUTPUT"

# Assembled to a scratch path, since the environment's deploy root is on the far host.
# Naming the root explicitly also marks this a bundle for shipping rather than an install.
- name: Assemble release bundle step
env:
HUGO_BASEURL: ${{ vars.HUGO_BASEURL }}
REQUIRE_BROTLI: '1'
run: |
set -Eeuo pipefail
deploy/make-release.sh "${RUNNER_TEMP}/bundle" "${{ steps.release.outputs.id }}"

- name: Install deploy key step
env:
DEPLOY_SSH_PRIVATE_KEY: ${{ secrets.DEPLOY_SSH_PRIVATE_KEY }}
DEPLOY_SSH_KNOWN_HOSTS: ${{ vars.DEPLOY_SSH_KNOWN_HOSTS }}
run: |
set -Eeuo pipefail
mkdir -p ~/.ssh
chmod 700 ~/.ssh
printf '%s\n' "$DEPLOY_SSH_PRIVATE_KEY" > ~/.ssh/deploy
chmod 600 ~/.ssh/deploy
printf '%s\n' "$DEPLOY_SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
chmod 600 ~/.ssh/known_hosts

# The destination is anchored at the key's confinement root.
# A full host path is remapped beneath that root and fails as an IO error.
# - link-dest points at current, which still resolves to the previous release until the flip.
# - mkpath creates releases/, which does not exist on a fresh environment.
# - delete is omitted, since at an environment root it silently removes rollback targets.
- name: Upload release step
env:
DEPLOY_SSH_USER: ${{ vars.DEPLOY_SSH_USER }}
DEPLOY_SSH_HOST: ${{ vars.DEPLOY_SSH_HOST }}
RELEASE_ID: ${{ steps.release.outputs.id }}
ENVIRONMENT: ${{ inputs.environment }}
run: |
set -Eeuo pipefail
rsync -az --mkpath --no-g --chmod=D2755,F644 \
--link-dest="/${ENVIRONMENT}/current/" \
-e "ssh -i ~/.ssh/deploy -o IdentitiesOnly=yes" \
"${RUNNER_TEMP}/bundle/releases/${RELEASE_ID}/" \
"${DEPLOY_SSH_USER}@${DEPLOY_SSH_HOST}:/${ENVIRONMENT}/releases/${RELEASE_ID}/"

# Separate from the upload, so a failed transfer cannot half-publish a site.
# rsync replaces the symlink through a temporary and a rename, so it is never absent.
- name: Flip current step
env:
DEPLOY_SSH_USER: ${{ vars.DEPLOY_SSH_USER }}
DEPLOY_SSH_HOST: ${{ vars.DEPLOY_SSH_HOST }}
ENVIRONMENT: ${{ inputs.environment }}
run: |
set -Eeuo pipefail
rsync -a --no-recursive \
-e "ssh -i ~/.ssh/deploy -o IdentitiesOnly=yes" \
"${RUNNER_TEMP}/bundle/current" \
"${DEPLOY_SSH_USER}@${DEPLOY_SSH_HOST}:/${ENVIRONMENT}/"

# The only step that observes the running site.
# An upload succeeds against a container serving nothing, and a flip without a config reload.
# The token pair is set on staging alone, since production answers unauthenticated.
- name: Verify URL contract step
env:
EXPECT_SITE_ENV: ${{ inputs.environment }}
EXPECT_RELEASE: ${{ steps.release.outputs.id }}
PANGOLIN_ACCESS_TOKEN_ID: ${{ secrets.PANGOLIN_ACCESS_TOKEN_ID }}
PANGOLIN_ACCESS_TOKEN: ${{ secrets.PANGOLIN_ACCESS_TOKEN }}
run: |
set -Eeuo pipefail
checks/check-live-urls.sh "${{ vars.HUGO_BASEURL }}"
51 changes: 51 additions & 0 deletions .github/workflows/deploy-site.yml
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
name: Deploy site action

on:
workflow_dispatch:
inputs:
environment:
description: Which environment to deploy.
required: true
type: choice
options:
- staging
- production

# Runs queue rather than cancel, because a cancelled deploy leaves a release uploaded and unflipped.
concurrency:
group: ${{ github.workflow }}-${{ inputs.environment }}
cancel-in-progress: false

jobs:

# Staging deploys from any ref, since proving a branch before it merges is what staging is for.
# First, so a mis-dispatched production deploy fails before anything is installed or written.
assert-ref:
name: Assert deploy ref job
runs-on: ubuntu-latest
steps:
- name: Assert ref matches environment step
run: |
set -Eeuo pipefail
if [ "${{ inputs.environment }}" = "production" ] && [ "${{ github.ref_name }}" != "main" ]; then
echo "::error::Deploy production from main; got ${{ github.ref_name }}."
exit 1
fi

# The same gate the pull request and a release run.
validate:
name: Validate sources job
needs: [ assert-ref ]
uses: ./.github/workflows/validate-task.yml
permissions:
contents: read

deploy:
name: Deploy site job
needs: [ validate ]
uses: ./.github/workflows/deploy-site-task.yml
with:
environment: ${{ inputs.environment }}
permissions:
contents: read
secrets: inherit
2 changes: 1 addition & 1 deletion .github/workflows/merge-bot-pull-request.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,7 +17,7 @@ on:

# Concurrency keys on the pull request number rather than github.ref.
# Under pull_request_target github.ref is the base branch, which would serialize every bot pull request.
# cancel-in-progress is false so a follow-up synchronize cannot cancel an in-flight opened run.
# Setting cancel-in-progress to false means a follow-up synchronize cannot cancel an in-flight opened run.
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
cancel-in-progress: false
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/publish-release.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -41,7 +41,7 @@ jobs:

# Full history for NBGV; pin the dispatch-time commit - a push landing after dispatch must not release unvalidated.
- name: Checkout code step
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.sha }}
fetch-depth: 0
Expand All@@ -56,7 +56,7 @@ jobs:
# The target_commitish input pins the tag to the exact built commit, not the default branch.
# The release is the tag plus GitHub's auto source archive, README, and LICENSE, with no build assets.
- name: Create GitHub release step
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
with:
generate_release_notes: true
tag_name: ${{ steps.nbgv.outputs.SemVer2 }}
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/validate-task.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -25,14 +25,14 @@ jobs:
steps:

- name: Checkout code step
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

# Doc linters run as pinned action wrappers.
# The editorconfig-checker action is install-only, so it runs via Docker instead.
# The markdown glob excludes the imported archive and the vendored theme.
# Neither is authored here, and .markdownlint-cli2.jsonc is carried verbatim so it cannot scope them.
- name: Lint Markdown step
uses: DavidAnson/markdownlint-cli2-action@8de2aa07cae85fd17c0b35642db70cf5495f1d25 # v24.0.0
uses: DavidAnson/markdownlint-cli2-action@6bf21b07787794f89a243495939cd651942aeabe # v24.1.0
with:
globs: |
**/*.md
Expand Down
24 changes: 15 additions & 9 deletions AGENTS.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,18 +2,22 @@

**Blog** is the source and deployment pipeline for a personal technical blog, a Hugo static site served by Caddy behind a reverse proxy. It holds the content, the media, the URL contract the site must honor, and the release tooling that builds and publishes it.

This file is the entry point every coding agent reads first, and it holds only two things: the rules for managing context and delegation, which apply to every task, and a map of where every other rule lives. The rule text itself is in [`GOVERNANCE.md`](./GOVERNANCE.md), one section per topic. Code style lives in [`CODESTYLE.md`](./CODESTYLE.md), the CI/CD workflow contract in [`WORKFLOW.md`](./WORKFLOW.md), and the deploy, rollback, and server procedures in [`OPERATIONS.md`](./OPERATIONS.md).
This file is the entry point every coding agent reads first, and it holds only three things: the bootstrap that says where the canonical rules live and which procedure to follow for the state this repository is actually in, the rules for managing context and delegation, which apply to every task, and a map of where every other rule lives. The rule text itself is in [`GOVERNANCE.md`](./GOVERNANCE.md), one section per topic. Code style lives in [`CODESTYLE.md`](./CODESTYLE.md), the CI/CD workflow contract in [`WORKFLOW.md`](./WORKFLOW.md), and the deploy, rollback, and server procedures in [`OPERATIONS.md`](./OPERATIONS.md).

Treat this file and `GOVERNANCE.md` as authoritative for cross-cutting rules, and do not restate their rules elsewhere. This project's own conventions and behavioral contracts live here, **not** in [`.github/copilot-instructions.md`](./.github/copilot-instructions.md), because that file targets GitHub Copilot / VS Code specifically, while this file and `GOVERNANCE.md` are the agent-agnostic ones every coding agent is directed to read, so any rule a reviewer must honor has to live in one of those two files to be provider-independent.
Treat this file and `GOVERNANCE.md` as authoritative for cross-cutting rules, and do not restate their rules elsewhere. This project's own conventions and behavioral contracts live in the topical doc that owns them, [`OPERATIONS.md`](./OPERATIONS.md) for how the site is run and verified and [`CODESTYLE.md`](./CODESTYLE.md) for how it is written, and **never** in [`.github/copilot-instructions.md`](./.github/copilot-instructions.md), because that file targets GitHub Copilot / VS Code specifically while the rest are agent-agnostic, so any rule a reviewer must honor has to live outside it to be provider-independent.

## Project Conventions
## Fleet Bootstrap

This site has served the same domain across earlier platforms, so its whole risk is silent URL loss. These bind every change.
This repository is governed by a shared template, and the canonical rules, machine-readable spec, and procedures live in `github.com/ptr727/ProjectTemplate`. Fetch that repository before acting on anything about conformance, carried content, repository settings, or standing a repository up, because a carried copy here can be stale or absent and the hub is the only authority on what this repository is supposed to hold. This section is byte-locked across every repository in the fleet, so it reads identically wherever it is found, and it is the entry point whenever nothing else present says where the rules are.

- **The URL contract is ground truth.** [`checks/golden-urls.txt`](./checks/golden-urls.txt) and [`checks/redirect-urls.txt`](./checks/redirect-urls.txt) record URLs verified with a live request. The lists are append-only. Nothing legitimately removes a URL the site has served.
- **Never populate media over HTTP.** The source export is the only trustworthy media source, because a hosted platform serves optimized derivatives at the same filename and path. Verify by content hash, never by file count.
- **`content/` is an archive.** It carries sixteen years of text. Prose, spelling, and style sweeps do not reach it, and rewriting it corrupts provenance rather than improving style.
- **A gate proves itself by failing.** Every check here is demonstrated against a deliberate break before it is trusted, because a gate that has only ever passed is indistinguishable from one that checks nothing.
Route by what this repository currently holds rather than by what it is expected to hold, since the two differ exactly when this section matters most.

- **No repository yet, or a local tree with no remote.** Follow the hub's `STANDUP.md` from section 0. That file is hub-only and deliberately not carried, because a repository needing it cannot be relied on to hold a current copy. Note that nothing in it creates the GitHub repository, which is an outward-facing write requiring explicit permission, so section 0A is the list handed to the maintainer before anything else starts.
- **A repository with no carried instruction set, or a partial one.** Carry the baseline per the hub's `STANDUP.md` sections 1A and 2, which resolve what this repository is owed from its declared types and workflow model. Absent files are not drift to re-vendor, they are a baseline that never arrived, and the two are fixed differently.
- **A repository with the instruction set, current or stale.** Follow the hub's `AUDIT.md` end to end, then apply what it finds per its section 10. An audit that reports drift and stops is half the procedure.
- **A repository that believes it is conformant.** Run the audit anyway and commit the report, because conformance asserted without a report is conformance nobody can check. This is the same procedure as the case above and is listed separately only because it is the one most often skipped.

Two rules bound every path above. **Read the hub's `main` branch as ground truth**, since that is the promoted and gated state, and read `develop` only to detect divergence. And **the audit is read-only**: it produces a report and never edits the repository it measures, so a fix is a separate, reviewable change.

## Context and Delegation Discipline

Expand All@@ -22,7 +26,7 @@ An agent session is billed on the context it carries, not the work it does. Ever
### Session Scope

- **One deliverable, one session.** A session covers one branch and one deliverable, and ends when that work merges. A multi-step task is one deliverable and stays in one session. Two unrelated tasks are two sessions even when they run back to back.
- **End a session at any of these, without being asked:** the branch changes, the pull request merges, the next task is unrelated to the last, or a third review round opens on the same pull request.
- **End a session at any of these, without being asked:** the branch changes, the pull request merges, or the next task is unrelated to the last. A review round is none of them. A loop still producing findings is the deliverable in progress, and a round count is not a reason to leave one open.
- **Hand off in a file, never in context.** Close a session by writing at most 2 KB to a scratch file: branch, pull request link, what is done, the next command. A summary held in context is re-billed until the session ends, and a summary on disk is read once by whoever needs it.
- **Re-derive state, do not carry it.** "This session already has the context" is the signal to split, not to continue. Context that has gone stale is worse than absent, because a file read hundreds of requests ago no longer describes the file.
- **Compaction is a fallback, not the strategy.** It restarts context from a floor and climbs again, where a fresh session starts from zero.
Expand DownExpand Up@@ -54,6 +58,7 @@ If a rule you were given does not cover what you find, stop and report it. Do no
```

- **Wait in a background process, not in a poll loop.** A review or CI wait is a sequence of near-identical requests, each billed for whatever context it happens to carry. Run the wait as one backgrounded command that returns when the condition is met.
- **A wait separates three outcomes, and says which one it reached.** The condition was met, it has not been met yet, and the wait cannot reach it at all are three different results, and a backgrounded wait that emits nothing renders all three identically. Run the command once in the foreground and read its output before backgrounding it, because a wait is only as good as the command inside it, and an unsupported flag on the installed tool version exits non-zero with an empty stdout that every naive test reads as "nothing yet". Never let a fallback stand in for a failed command, since `|| echo '[]'`, `|| true`, and `2>/dev/null` convert an error into that same reading, which is the suppression the write-safety rules already forbid on a mutation. Make the wait emit on failure as loudly as on success, so silence means "still running" and nothing else, and bound it, so a condition that is never coming ends in a report rather than in another wait.

## Where the Rules Live

Expand All@@ -64,6 +69,7 @@ Every rule below is a level-two section of [`GOVERNANCE.md`](./GOVERNANCE.md). R
| Why the rules are shaped this way | `Foundational Principles` |
| Recording a durable lesson or updating governance | `Durable Knowledge and Self-Improvement` |
| Any push, API mutation, comment, label, or merge | `Repository Boundaries and Write Safety` |
| Quoting data into a comment, commit, test, or doc | `Representative Data in Agent-Authored Text` |
| Committing, signing, rebasing, force-pushing | `Git and Commit Rules` |
| Branch choice, promotion, keeping branches in sync | `Branching Model` |
| Releasing, version bumps, publishing | `Release Model` |
Expand Down
Loading