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
16 changes: 16 additions & 0 deletions .github/workflows/deploy-site-task.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ name: Deploy site task
# It runs three times, once each for build, prune, and verify, passing mode plus whichever of bundle-path, release-id, and environment that mode needs.
# A hook declares all four inputs in its own action.yml, since a composite action rejects an invocation that supplies an input it does not declare, even one a different mode leaves unset.
# A composite action's own steps are not guaranteed to read the caller's vars context directly, so each invocation also passes the GitHub Environment variables that mode needs (SITE_BASE_URL, DEPLOY_SSH_USER, DEPLOY_SSH_HOST) as plain env vars, the same mechanism the upload and flip steps below use.
# The verify invocation also carries an optional SITE_AUTH_TOKEN_ID/SITE_AUTH_TOKEN secret pair the same way, for a hook whose live check needs its own token-gated auth.
# The upload and the flip stay here as the one atomic sequence every site repo shares, so a hook cannot fork that guarantee.
#
# The transport's options are pinned rather than left to the runner's OpenSSH defaults, and declared once so the two transfers cannot drift apart.
Expand All @@ -31,6 +32,12 @@ on:
# The deploy job below reads them directly through its own environment binding.
DEPLOY_SSH_PRIVATE_KEY:
required: true
# Optional token-gated auth pair for the verify hook's own live check, required: false since not every caller needs one.
# Checked as a pair by the assert step below, then forwarded to the hook only on the verify invocation.
SITE_AUTH_TOKEN_ID:
required: false
SITE_AUTH_TOKEN:
required: false
outputs:
# The caller records what shipped, without this a rollback has to read the host to find out.
release-id:
Expand Down Expand Up @@ -101,6 +108,8 @@ jobs:
DEPLOY_SSH_HOST: ${{ vars.DEPLOY_SSH_HOST }}
DEPLOY_SSH_KNOWN_HOSTS: ${{ vars.DEPLOY_SSH_KNOWN_HOSTS }}
DEPLOY_SSH_PRIVATE_KEY: ${{ secrets.DEPLOY_SSH_PRIVATE_KEY }}
SITE_AUTH_TOKEN_ID: ${{ secrets.SITE_AUTH_TOKEN_ID }}
SITE_AUTH_TOKEN: ${{ secrets.SITE_AUTH_TOKEN }}
run: |
set -Eeuo pipefail
missing=()
Expand All @@ -113,6 +122,11 @@ jobs:
echo "::error::Missing or empty GitHub Environment value(s) on '${{ inputs.environment }}': ${missing[*]}"
exit 1
fi
# SITE_AUTH_TOKEN_ID and SITE_AUTH_TOKEN are each optional, but only together: a caller that maps one without the other reaches the verify hook as a silent partial credential rather than a caught mistake.
if { [ -n "$SITE_AUTH_TOKEN_ID" ] && [ -z "$SITE_AUTH_TOKEN" ]; } || { [ -z "$SITE_AUTH_TOKEN_ID" ] && [ -n "$SITE_AUTH_TOKEN" ]; }; then
echo "::error::SITE_AUTH_TOKEN_ID and SITE_AUTH_TOKEN must both be mapped or neither; got only one."
exit 1
fi

# Derived once and used three times: the directory name, the stamp the bundle carries, and the value the live check expects.
# Deriving it twice yields ids seconds apart, and the check then asserts a version nothing installed.
Expand Down Expand Up @@ -225,6 +239,8 @@ jobs:
uses: ./.github/actions/deploy
env:
SITE_BASE_URL: ${{ vars.SITE_BASE_URL }}
SITE_AUTH_TOKEN_ID: ${{ secrets.SITE_AUTH_TOKEN_ID }}
SITE_AUTH_TOKEN: ${{ secrets.SITE_AUTH_TOKEN }}
Comment thread
ptr727 marked this conversation as resolved.
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
with:
mode: verify
environment: ${{ inputs.environment }}
Expand Down
7 changes: 0 additions & 7 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,12 +195,6 @@ The hosted gates, release chain, Docker core, and type-specific tasks are implem
- **Checked** - Not measured. Raised by the maintainer on 2026-08-15 while reviewing the Docker family design, and the first action is a read of the five Dockerfiles.
- **Open** - Whether the shape is a `CODESTYLE.md` section, a `docker` type check, or both.

- **Give `deploy-site-task.yml`'s hook invocations a secret-forwarding path.** Only GitHub Environment variables cross into a hook invocation today (`SITE_BASE_URL`, `DEPLOY_SSH_USER`, `DEPLOY_SSH_HOST`). Blog's `verify` mode needs a secret pair for its staging auth gate, and no hook invocation receives it.
- **Blocked by** - Nothing.
- **Issue** - [#929][issue-929].
- **Checked** - `develop` at `fc489dd` on 2026-08-22, against `deploy-site-task.yml`'s three hook invocations and Blog's `checks/check-live-urls.sh`.
- **Open** - The shape, a named secret pair or a generic passthrough, per the issue.

### Review Cost and the Local Review Pass

One pull request, after a measurement, stating what change size licenses and whether a local adversarial pass earns its place, which are one question because both are about where review cost goes.
Expand Down Expand Up @@ -532,7 +526,6 @@ Regenerate [reports/divergences.md][divergences-report] before using it as the w
[issue-672]: https://github.com/ptr727/ProjectTemplate/issues/672
[issue-673]: https://github.com/ptr727/ProjectTemplate/issues/673
[issue-767]: https://github.com/ptr727/ProjectTemplate/issues/767
[issue-929]: https://github.com/ptr727/ProjectTemplate/issues/929
[issue-931]: https://github.com/ptr727/ProjectTemplate/issues/931

<!-- Pull requests -->
Expand Down
9 changes: 5 additions & 4 deletions docs/reusable-workflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ The sequencing consequence is that a hub task lands on `develop`, promotes to `m

### Secrets and Permissions

Every hub task declares the secrets it needs by name under `on.workflow_call.secrets`, each `required: true`, and a caller maps each one explicitly. `secrets: inherit` is not used, since it is documented for a caller in the same organization or enterprise as the called workflow and the fleet is a personal account. The declared names are the ones [`spec/secrets.json`][secrets] already declares for the mechanism the task implements, so the secret audit and the workflow agree by construction.
Every hub task declares the secrets it needs by name under `on.workflow_call.secrets`, and a caller maps each one explicitly. Most are `required: true`. A mechanism's secret is `required: false` where the task treats it as one of several opt-in targets, such as `NUGET_USERNAME` and `DOCKER_HUB_USERNAME`/`DOCKER_HUB_ACCESS_TOKEN` in `build-release-task.yml`. The same names are `required: true` in a task built around that one mechanism instead, such as `DOCKER_HUB_USERNAME`/`DOCKER_HUB_ACCESS_TOKEN` in `build-docker-task.yml`. `secrets: inherit` is not used, since it is documented for a caller in the same organization or enterprise as the called workflow and the fleet is a personal account. The declared names are the ones [`spec/secrets.json`][secrets] already declares for the mechanism the task implements, so the secret audit and the workflow agree by construction. An environment-scoped secret is the exception: `DEPLOY_SSH_PRIVATE_KEY`, and the `SITE_AUTH_TOKEN_ID`/`SITE_AUTH_TOKEN` pair beside it, cross a GitHub Environment boundary `spec/secrets.json` has no vocabulary for, per its `deploy-ssh` mechanism note.

A hub task declares no job-level `permissions:` where every write goes through the App token, and the caller sets `permissions: {}`. A called workflow can only keep or reduce the caller's grant. A callee job naming a scope the caller did not grant fails at startup even when its `if:` is false. Declaring nothing in the callee is therefore the shape that cannot fail against any caller, and it gives `GITHUB_TOKEN` no scope. A task whose job genuinely writes with `GITHUB_TOKEN`, such as a release upload, declares that scope in the callee job and documents it in the stub's comment so the caller grants it.

Expand Down Expand Up @@ -214,7 +214,7 @@ Hub: `publish-docker-readme-task.yml` with a `docker-readme-transform` hook, `ch
- [ ] Catalog snippets for `publish-docker-readme-task.yml`, `check-upstream-version-task.yml`, `deploy-site.yml`, `deploy-site-task.yml`, and `run-codegen-pull-request-task.yml` pinned to the release that first carries each task. `catalog/snippets/workflows/run-periodic-codegen-pull-request.yml` now exists, since [Codegen](#adopting-the-type-specific-tasks) already states it keeps the same per-repo shape as today. The other four stay open: `publish-docker-readme-task.yml` and `check-upstream-version-task.yml` are each a job embedded in a repo's own workflow rather than a standalone top-level caller with a snippet of its own, and `deploy-site.yml` carries no manifest-wide snippet by design, since each site's own shape varies around the shared `deploy` job.
- [ ] `reports/workflow-reuse.md` regenerated, and the fleet total's callers equal to the sum of the stubs the fleet needs.
- [ ] The environment-secret handoff in the deploy-site adoption, the caller job's own `environment:` binding resolving `DEPLOY_SSH_PRIVATE_KEY` for an explicit `secrets:` map across a cross-repository `uses:`, observed on Blog's first live deploy run. Tick with the run URL.
- [ ] The `deploy` hook's `verify` mode needs a same-shaped secret handoff as `DEPLOY_SSH_PRIVATE_KEY` above it, for a repo that gates a non-production environment behind a token, such as Blog's `PANGOLIN_ACCESS_TOKEN_ID`/`PANGOLIN_ACCESS_TOKEN` pair for `checks/check-live-urls.sh`. `deploy-site-task.yml` declares no such secret today, so nothing carries it from the caller's environment into the `verify` invocation. Decide the shape, one named pair or a generic passthrough, before Blog's adoption below can tick.
- [x] The `deploy` hook's `verify` mode gained a same-shaped secret handoff as `DEPLOY_SSH_PRIVATE_KEY` above it, the case Blog's `checks/check-live-urls.sh` raised against its own staging environment's token-gated auth. `deploy-site-task.yml` declares an optional, generic `SITE_AUTH_TOKEN_ID`/`SITE_AUTH_TOKEN` pair and forwards it into the `verify` invocation as `env:`, the named-pair shape decided in [issue #929][issue-929]. Which product gates a given environment, and how a caller maps that product's own secrets to these two names, stays that repo's concern rather than the hub's.
- [ ] The default `docker-readme-transform` action resolving through `$/` at the caller's pinned hub commit, observed on a caller with no override hook. Tick with the run URL.
- [ ] `$/` recognized by a released actionlint, so the scoped `.github/actionlint.yaml` ignores can drop.

Expand Down Expand Up @@ -547,13 +547,13 @@ The caller grants `contents: read` explicitly, since the task's own jobs declare

ESPHome-NonRoot carries two trackers today. `check-upstream-version.yml` adopts the stub above as-is. `check-upstream-dependency.yml`, whose bump waits for a human because its head deliberately does not match a merge-bot rule, adopts a second instance of the same stub with `with: { branches: '["develop"]', bump-branch-prefix: upstream-dependency, auto-merge: false }` and a `resolve-upstream` hook shaped around its apt-package snapshot, setting `versions` to `{"docker_base_packages": "<sorted, comma-joined package list>"}` rather than a name-to-version map. The generic title and body this produces read less specifically than today's bespoke "packages added/removed" wording, which is the cost of folding a bespoke tracker into the shared task.

**Deploy-site.** A site repo keeps `deploy-site.yml` as a per-repo caller (it has no manifest-wide catalog snippet either, since its `uses:` now names the hub, and it still carries the dispatch, the ref gate, and the shared validation call), but its `deploy` job reaches the hub-hosted `deploy-site-task.yml` and binds the same `environment:` the task binds, which is what lets the one crossing secret, `DEPLOY_SSH_PRIVATE_KEY`, resolve from the GitHub Environment store at the call site rather than through `secrets: inherit`, unusable across repositories. A required `deploy` hook, `.github/actions/deploy/action.yml`, is invoked three times (`build`, `prune`, `verify`) so the site keeps its own generator, precompression, and URL contract while the upload-then-flip sequence stays hub-owned. The hook declares all four inputs the three invocations use between them, `mode`, `bundle-path`, `release-id`, and `environment`, since a composite action rejects an invocation that supplies an input it does not declare, even one a different mode leaves unset. Each invocation also passes the GitHub Environment variables that mode needs (`SITE_BASE_URL` to `build` and `verify`, `DEPLOY_SSH_USER` and `DEPLOY_SSH_HOST` to `prune`) as plain `env:` vars, since a composite action's own steps are not guaranteed to read the caller's `vars` context directly.
**Deploy-site.** A site repo keeps `deploy-site.yml` as a per-repo caller (it has no manifest-wide catalog snippet either, since its `uses:` now names the hub, and it still carries the dispatch, the ref gate, and the shared validation call), but its `deploy` job reaches the hub-hosted `deploy-site-task.yml` and binds the same `environment:` the task binds, which is what lets the one crossing secret, `DEPLOY_SSH_PRIVATE_KEY`, resolve from the GitHub Environment store at the call site rather than through `secrets: inherit`, unusable across repositories. A required `deploy` hook, `.github/actions/deploy/action.yml`, is invoked three times (`build`, `prune`, `verify`) so the site keeps its own generator, precompression, and URL contract while the upload-then-flip sequence stays hub-owned. The hook declares all four inputs the three invocations use between them, `mode`, `bundle-path`, `release-id`, and `environment`, since a composite action rejects an invocation that supplies an input it does not declare, even one a different mode leaves unset. Each invocation also passes the GitHub Environment variables that mode needs (`SITE_BASE_URL` to `build` and `verify`, `DEPLOY_SSH_USER` and `DEPLOY_SSH_HOST` to `prune`) as plain `env:` vars, since a composite action's own steps are not guaranteed to read the caller's `vars` context directly. `verify` additionally receives an optional `SITE_AUTH_TOKEN_ID`/`SITE_AUTH_TOKEN` secret pair the same way, forwarded whenever the caller maps it, for a site whose live check sits behind its own token-gated auth.

Blog is the reference adoption, and its real inventory is two scripts, not three: `deploy/make-release.sh` assembles, hard-links, stamps, and installs a release into whatever root it is pointed at, and `checks/check-live-urls.sh` verifies one against a running server. There is no `deploy/prune-releases.sh`. `build` mode wraps `make-release.sh` pointed at the hub-passed `bundle-path` rather than a live root, alongside whatever generator setup the hook itself needs, Hugo and brotli in Blog's case, that `make-release.sh` assumes are already on `PATH`. The script's own tail, a swap of a local `current` symlink to the release it just wrote and a check that the swap hard-linked something against whatever `current` pointed at before, runs entirely against that ephemeral `bundle-path`, so it is local bookkeeping rather than a second real deploy. It is also what leaves `bundle-path/current` in place for the hub task's own build-mode assertion to find. Because `bundle-path` is empty at the start of every run, that local `current` never resolves to anything and the hard-link check never has a previous release to compare against, so it is inert in CI. The `build` hook's own `current` is never the live one either way: only the hub-owned Upload release and Flip current steps that follow touch the real `/<environment>/` root, so the boundary the upload-then-flip sequence draws is between `bundle-path` and the environment, not a seam inside `make-release.sh` itself.

`prune` mode is a no-op for Blog. Its deploy credential is a forced `rsync` command confined write-only, so it can neither list nor delete the remote destination, and retention there is owned by the host's own daily timer instead, which Blog's own `OPERATIONS.md` records by name next to that ownership line. A site whose credential can observe its own destination prunes for real in this mode instead, the case `deploy-site-task.yml`'s own comment on that step already anticipates.

`verify` mode wraps `checks/check-live-urls.sh`, reading the `SITE_BASE_URL` `env:` var the same way `build` mode does, plus, for Blog's staging auth gate, a `PANGOLIN_ACCESS_TOKEN_ID`/`PANGOLIN_ACCESS_TOKEN` pair the task does not forward at all, since today only `SITE_BASE_URL`, `DEPLOY_SSH_USER`, and `DEPLOY_SSH_HOST` cross into any hook invocation. That gap is tracked below.
`verify` mode wraps `checks/check-live-urls.sh`, reading the `SITE_BASE_URL` `env:` var the same way `build` mode does, plus the optional `SITE_AUTH_TOKEN_ID`/`SITE_AUTH_TOKEN` pair, since Blog's staging environment sits behind its own token-gated auth proxy. Which product gates that environment, and how Blog's own caller maps its secrets to those two generic names, is Blog's concern to document in its own repository, not the hub's: a further adopter behind a different product's token gate needs no hub change and no new sentence here, only its own caller mapping its own secret names to `SITE_AUTH_TOKEN_ID`/`SITE_AUTH_TOKEN`.

A hook, not a fixed path convention, is still the better contract even at two scripts: Blog's own generator setup (`install-hugo`, the mtime restore) and its staging auth check are exactly the per-site variation a hardcoded script name could not absorb.

Expand Down Expand Up @@ -611,6 +611,7 @@ Four things the hub cannot prove fall to the first downstream adopter. They are
[governance-hub-hosted-tooling]: ../GOVERNANCE.md#hub-hosted-tooling
[governance-workflow-yaml-conventions]: ../GOVERNANCE.md#workflow-yaml-conventions
[issue-585]: https://github.com/ptr727/ProjectTemplate/issues/585
[issue-929]: https://github.com/ptr727/ProjectTemplate/issues/929
[no-build-caller-snippet]: ../catalog/snippets/workflows/test-pull-request.yml
[override-path-run]: https://github.com/ptr727/ProjectTemplate/actions/runs/31950332387/job/95172710046
[pilot-publish-run]: https://github.com/ptr727/PhotoCleaner/actions/runs/31977092102
Expand Down