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
2 changes: 1 addition & 1 deletion .github/workflows/deploy-site-task.yml
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ jobs:
esac

# Every host-specific value comes from the environment, so this file names no host, path, or address.
# The caller's own job binds the same environment, which is what makes the secret mapped into DEPLOY_SSH_PRIVATE_KEY below resolve from the environment store rather than from the repository store.
# The caller still maps DEPLOY_SSH_PRIVATE_KEY via workflow_call.secrets (required below), but this job's own binding is what determines the real value: cross-repository it resolves from the caller's environments, per docs/reusable-workflows.md "Deploy-site." (a caller job with uses: cannot carry environment: itself).
deploy:
name: Deploy site job
runs-on: ubuntu-latest
Expand Down
8 changes: 5 additions & 3 deletions docs/reusable-workflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,7 @@ Hub: `publish-docker-readme-task.yml` with a `docker-readme-transform` hook, `ch
- [ ] KiCadLibrary (deletes `build-datebadge-task.yml` and its caller job outright, per TODO.md's retired-badge cleanup, adopting no new stub)
- [ ] 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.
- [x] The environment-secret handoff in the deploy-site adoption, the task's own `environment:` binding (not the caller's, which cannot carry one, [issue #942][issue-942]) resolving `DEPLOY_SSH_PRIVATE_KEY` from the caller's environment store across a cross-repository `uses:`. [Confirmed against Blog's own `staging` environment][run-cross-repo-secret-probe]: `DEPLOY_SSH_PRIVATE_KEY` resolved (masked, non-empty), and only the separately-tracked `SITE_BASE_URL` naming mismatch was missing.
- [x] The `deploy` hook's `verify` mode gained a same-shaped secret handoff as `DEPLOY_SSH_PRIVATE_KEY` above it. Blog's `checks/check-live-urls.sh` raised the case, 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 its secrets to these two names, stays that repo's concern, not 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,7 +547,7 @@ 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. `verify` additionally receives an optional `SITE_AUTH_TOKEN_ID`/`SITE_AUTH_TOKEN` secret pair the same way, forwarded whenever the caller maps it. A site whose live check sits behind its own token-gated auth is the reason it exists.
**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`. A job calling a reusable workflow cannot itself carry an `environment:` key ([GitHub's supported-keywords list][gh-reusing-workflows] omits it, and GitHub rejects the combination outright), so the caller's `deploy` job carries none. `DEPLOY_SSH_PRIVATE_KEY` still crosses correctly: the task's own `deploy` job binds `environment: ${{ inputs.environment }}` on itself, and per GitHub's own cross-repository behavior (the `github` context, and an OIDC token's `sub` claim, always attribute the environment to the *caller*), that resolves against the caller's own GitHub Environment store, not the hub's. The caller's `secrets: DEPLOY_SSH_PRIVATE_KEY: ${{ secrets.DEPLOY_SSH_PRIVATE_KEY }}` line is still required (the task declares this a required `workflow_call.secrets` input, and GitHub rejects a call omitting a required one), but what value the task's own job actually sees for it is governed by the task's own `environment:` binding, not by anything the caller's own (environment-less) job context could resolve. Confirmed both against GitHub's documented reusable-workflow secrets mechanics and with a live cross-repository run against Blog's own `staging` environment ([issue #942][issue-942]). `secrets: inherit` was never the alternative here regardless, since it does not carry environment-scoped secrets across a cross-repository call. 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. A site whose live check sits behind its own token-gated auth is the reason it exists.

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.

Expand All @@ -561,7 +561,6 @@ A hook, not a fixed path convention, is still the better contract even at two sc
deploy:
name: Deploy job
needs: [validate]
environment: ${{ inputs.environment }}
permissions:
contents: read
uses: ptr727/ProjectTemplate/.github/workflows/deploy-site-task.yml@<sha> # <tag>
Expand Down Expand Up @@ -608,16 +607,19 @@ Four things the hub cannot prove fall to the first downstream adopter. They are

<!-- Repo -->

[gh-reusing-workflows]: https://docs.github.com/en/actions/reference/workflows-and-actions/reusing-workflow-configurations
[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
[issue-942]: https://github.com/ptr727/ProjectTemplate/issues/942
[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
[pilot-smoke-run]: https://github.com/ptr727/PhotoCleaner/actions/runs/31974932749
[self-reference-smoke-run]: https://github.com/ptr727/PhotoCleaner/actions/runs/32047594855
[pr-760]: https://github.com/ptr727/ProjectTemplate/pull/760
[run-cross-repo-secret-probe]: https://github.com/ptr727/Blog/actions/runs/32618245296
[run-770]: https://github.com/ptr727/ProjectTemplate/actions/runs/31972611554
[run-771]: https://github.com/ptr727/ProjectTemplate/actions/runs/31972622149
[run-startup-failure]: https://github.com/ptr727/ProjectTemplate/actions/runs/31972504539
Expand Down
43 changes: 33 additions & 10 deletions spec/audit.py
Original file line number Diff line number Diff line change
Expand Up @@ -1541,11 +1541,11 @@ def check_interface(path, contract, text):
block = _code_view(jobs[job])
for t in toks:
if t in block:
# Generic rather than tied to one contract's reason.
# forbidTokensInJob guards more than the github-release seam.
# intentRef on the file's own entry routes a reader to the actual context.
findings.append(
(
"DRIFT",
f"interface: {path} job '{job}' uses forbidden '{t}' (forks the verbatim github-release download, see WORKFLOW.md \"The Seam Contract\")",
)
("DRIFT", f"interface: {path} job '{job}' uses forbidden '{t}'")
)
return findings

Expand Down Expand Up @@ -2677,13 +2677,13 @@ def _selftest():
]
# The deploy-site.yml caller stub once deploy-site-task.yml is hub-hosted: no secrets: inherit
# (a cross-repository reusable workflow cannot use it), the one crossing secret named instead.
# No job-level environment: on the caller, unsupported on a job with uses: (ptr727/ProjectTemplate#942).
deploy_stub = (
"jobs:\n"
" assert-ref:\n runs-on: ubuntu-latest\n steps: []\n"
" validate:\n uses: ./.github/workflows/validate-task.yml\n"
" deploy:\n"
" name: Deploy job\n"
" environment: ${{ inputs.environment }}\n"
" permissions:\n contents: read\n"
" uses: acme/hub/.github/workflows/deploy-site-task.yml@" + "a" * 40 + " # 2.0.1\n"
" with:\n environment: ${{ inputs.environment }}\n"
Expand All @@ -2694,12 +2694,19 @@ def _selftest():
"requireTokensInJob": {
"deploy": [
"deploy-site-task.yml",
# Indent-anchored (4 spaces) so a with: input of the same name, indented 6, cannot satisfy this on its own.
"\n environment:",
# Anchored to the with: block specifically, not just 6-space indent.
# A secret named "environment" would also land at that indent otherwise.
# The task declares this input required, and a missing one fails at dispatch, not at audit time.
"with:\n environment:",
"contents: read",
"DEPLOY_SSH_PRIVATE_KEY",
]
},
"forbidTokensInJob": {
# Indent-anchored (4 spaces) to the job's own top level, the shape GitHub rejects
# at parse time on a job with uses: (ptr727/ProjectTemplate#942).
"deploy": ["\n environment:"]
},
Comment thread
ptr727 marked this conversation as resolved.
}
cases += [
(
Expand All @@ -2718,11 +2725,27 @@ def _selftest():
1,
),
(
"deploy-site.yml caller stub missing the environment binding the crossing secret needs",
# Removes only the job-level environment: line, leaving the with:-nested environment: input untouched, the exact ambiguity an unanchored token would miss.
"deploy-site.yml caller stub reintroducing the invalid job-level environment: key",
deploy_stub.replace(
" name: Deploy job\n environment: ${{ inputs.environment }}\n",
" name: Deploy job\n",
" name: Deploy job\n environment: ${{ inputs.environment }}\n",
),
deploy_contract,
1,
),
(
"deploy-site.yml caller stub missing the required environment input",
deploy_stub.replace(" with:\n environment: ${{ inputs.environment }}\n", ""),
deploy_contract,
1,
),
(
"deploy-site.yml caller stub with environment: under the wrong mapping still reports missing",
# A same-indented environment: key under secrets: must not satisfy the with:-anchored requirement on its own.
# The oddly-named secret's value is left generic so it does not incidentally satisfy the DEPLOY_SSH_PRIVATE_KEY token.
deploy_stub.replace(
" with:\n environment: ${{ inputs.environment }}\n",
" secrets:\n environment: ${{ secrets.SOME_OTHER_SECRET }}\n",
),
deploy_contract,
1,
Expand Down
2 changes: 1 addition & 1 deletion spec/files.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
{ "path": ".github/workflows/test-pull-request.yml", "fidelity": "interface", "contract": { "requiredJobKeys": ["check-workflow-status", "validate"], "requiredCheckName": "Check pull request workflow status job", "requireTokensInJob": { "validate": ["validate-task.yml"] } }, "intentRef": "GOVERNANCE.md#workflow-yaml-conventions", "appliesTo": "*" },
{ "path": ".github/workflows/publish-release.yml", "fidelity": "interface", "contract": { "requiredJobKeys": ["plan", "validate", "publish"], "requireTokensInJob": { "plan": ["publish-plan-task.yml"], "validate": ["validate-task.yml"], "publish": ["build-release-task.yml", "needs.validate.result == 'success'"] } }, "intentRef": "WORKFLOW.md#d4---release--publish", "appliesTo": ["two-phase", "dispatch-only", "publish-on-merge"] },
{ "path": ".github/workflows/merge-bot-pull-request.yml", "fidelity": "interface", "contract": { "requiredJobKeys": ["merge-bot"], "requireTokensInJob": { "merge-bot": ["merge-bot-task.yml", "CODEGEN_APP_CLIENT_ID", "CODEGEN_APP_PRIVATE_KEY"] } }, "intentRef": "WORKFLOW.md#d8---bots--automation", "appliesTo": "*" },
{ "path": ".github/workflows/deploy-site.yml", "fidelity": "interface", "contract": { "requiredJobKeys": ["assert-ref", "validate", "deploy"], "requireTokensInJob": { "deploy": ["deploy-site-task.yml", "\n environment:", "contents: read", "DEPLOY_SSH_PRIVATE_KEY"] } }, "intentRef": "docs/reusable-workflows.md#adopting-the-type-specific-tasks", "appliesTo": ["hugo"] },
{ "path": ".github/workflows/deploy-site.yml", "fidelity": "interface", "contract": { "requiredJobKeys": ["assert-ref", "validate", "deploy"], "requireTokensInJob": { "deploy": ["deploy-site-task.yml", "with:\n environment:", "contents: read", "DEPLOY_SSH_PRIVATE_KEY"] }, "forbidTokensInJob": { "deploy": ["\n environment:"] } }, "intentRef": "docs/reusable-workflows.md#adopting-the-type-specific-tasks", "appliesTo": ["hugo"] },
Comment thread
ptr727 marked this conversation as resolved.
{ "path": ".vscode/tasks.json", "sections": ["clean-compile task group"], "reference": "catalog/snippets/configs/vscode-tasks.json", "appliesTo": ["csharp"] },
{ "path": ".vscode/tasks.json", "sections": ["clean-compile task group"], "reference": "catalog/snippets/configs/vscode-tasks-python.json", "appliesTo": ["python"] },
{ "path": "codecov.yml", "fidelity": "intent", "reference": "catalog/snippets/configs/codecov.yml", "intentRef": "WORKFLOW.md", "appliesTo": ["csharp", "python"] },
Expand Down