diff --git a/.github/workflows/deploy-site-task.yml b/.github/workflows/deploy-site-task.yml index 1d69cd5a..51bcede7 100644 --- a/.github/workflows/deploy-site-task.yml +++ b/.github/workflows/deploy-site-task.yml @@ -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 diff --git a/docs/reusable-workflows.md b/docs/reusable-workflows.md index 192feea4..83bc704b 100644 --- a/docs/reusable-workflows.md +++ b/docs/reusable-workflows.md @@ -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. @@ -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": ""}` 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 `//` 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. @@ -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@ # @@ -608,16 +607,19 @@ Four things the hub cannot prove fall to the first downstream adopter. They are +[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 diff --git a/spec/audit.py b/spec/audit.py index b7831303..6101d71a 100755 --- a/spec/audit.py +++ b/spec/audit.py @@ -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 @@ -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" @@ -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:"] + }, } cases += [ ( @@ -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, diff --git a/spec/files.json b/spec/files.json index 4be0854c..3f21366c 100644 --- a/spec/files.json +++ b/spec/files.json @@ -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"] }, { "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"] },