The Actions release reads its token from ACTIONS_PUBLISH_TOKEN. It asked for G1T_ACTIONS_PUBLISH_TOKEN, a name g1t keeps for its own and refuses when a secret is saved, so the secret it needed could never be set. A test now fails any of g1t's workflows that reads a secret named G1T_ or GITHUB_ other than the job's own token.
2 files+26−50/2 viewed
| 22 | 22 | # | |
| 23 | 23 | # Runs on a push to main that changes actions/ or workflows/, and by hand. | |
| 24 | 24 | # | |
| 25 | − | # Needs the repository secret G1T_ACTIONS_PUBLISH_TOKEN: an access token of | |
| 25 | + | # Needs the repository secret ACTIONS_PUBLISH_TOKEN: an access token of | |
| 26 | 26 | # the workspace g1t leads to (flagon-io), with the scopes repo:write (to | |
| 27 | 27 | # create a missing repository) and code:write (to push and move tags). | |
| 28 | 28 | # A token without repo:write still publishes to repositories | |
| ⋯ | |||
| 61 | 61 | fetch-depth: 0 | |
| 62 | 62 | - name: Push each action and move its major tag | |
| 63 | 63 | env: | |
| 64 | − | PUBLISH_TOKEN: ${{ secrets.G1T_ACTIONS_PUBLISH_TOKEN }} | |
| 64 | + | PUBLISH_TOKEN: ${{ secrets.ACTIONS_PUBLISH_TOKEN }} | |
| 65 | 65 | SERVER: ${{ github.server_url }} | |
| 66 | 66 | API: ${{ github.api_url }} | |
| 67 | 67 | run: | | |
| 68 | 68 | set -euo pipefail | |
| 69 | 69 | if [ -z "${PUBLISH_TOKEN}" ]; then | |
| 70 | − | echo "::error::Set the repository secret G1T_ACTIONS_PUBLISH_TOKEN (this workflow's header says what it needs)." | |
| 70 | + | echo "::error::Set the repository secret ACTIONS_PUBLISH_TOKEN (this workflow's header says what it needs)." | |
| 71 | 71 | exit 1 | |
| 72 | 72 | fi | |
| 73 | 73 | echo "::add-mask::${PUBLISH_TOKEN}" | |
| ⋯ | |||
| 101 | 101 | | curl -sS -o "${found}" -w '%{http_code}' -X POST -H "${bearer}" -H "Content-Type: application/json" \ | |
| 102 | 102 | --data-binary @- "${API}/repos")" | |
| 103 | 103 | if [ "${status}" != "201" ] && [ "${status}" != "200" ]; then | |
| 104 | − | echo "::error::Creating ${NAMESPACE}/${name} answered ${status}: $(cat "${found}"). Give G1T_ACTIONS_PUBLISH_TOKEN the repo:write scope, or create ${NAMESPACE}/${name} once by hand, public." | |
| 104 | + | echo "::error::Creating ${NAMESPACE}/${name} answered ${status}: $(cat "${found}"). Give ACTIONS_PUBLISH_TOKEN the repo:write scope, or create ${NAMESPACE}/${name} once by hand, public." | |
| 105 | 105 | exit 1 | |
| 106 | 106 | fi | |
| 107 | 107 | echo "- Created \`$(jq -r '.namespace + "/" + .name' "${found}")\` for \`${NAMESPACE}/${name}\`" >> "${GITHUB_STEP_SUMMARY}" | |
| 306 | 306 | assert!(release.trigger("workflow_dispatch").is_some()); | |
| 307 | 307 | let source = std::fs::read_to_string(workflows_dir().join("actions-release.yml")).unwrap(); | |
| 308 | 308 | assert!(source.contains("actions/**")); | |
| 309 | − | assert!(source.contains("secrets.G1T_ACTIONS_PUBLISH_TOKEN")); | |
| 309 | + | assert!(source.contains("secrets.ACTIONS_PUBLISH_TOKEN")); | |
| 310 | 310 | // Every action is a composite one with its README, and the copies of | |
| 311 | 311 | // the script that finds g1t-runner are the same file. | |
| 312 | 312 | let actions = workflows_dir().join("../../actions"); | |
| ⋯ | |||
| 349 | 349 | assert_eq!(workflow.triggers.iter().map(|t| t.event.as_str()).collect::<Vec<_>>(), ["workflow_dispatch"]); | |
| 350 | 350 | assert!(!ours.contains("secrets.")); | |
| 351 | 351 | } | |
| 352 | + | ||
| 353 | + | /// No workflow of g1t's asks for a secret no one can set. Secret names | |
| 354 | + | /// starting with G1T_ or GITHUB_ are g1t's own and refused when saved, so a | |
| 355 | + | /// workflow reading one gets nothing: the Actions release asked for | |
| 356 | + | /// G1T_ACTIONS_PUBLISH_TOKEN, which could never exist. G1T_TOKEN, the job's | |
| 357 | + | /// own token, is the one g1t gives every job. | |
| 358 | + | #[test] | |
| 359 | + | fn workflows_read_only_secrets_that_can_be_set() { | |
| 360 | + | for entry in std::fs::read_dir(workflows_dir()).unwrap() { | |
| 361 | + | let path = entry.unwrap().path(); | |
| 362 | + | if path.extension().is_none_or(|ext| ext != "yml") { | |
| 363 | + | continue; | |
| 364 | + | } | |
| 365 | + | let source = std::fs::read_to_string(&path).unwrap(); | |
| 366 | + | for (at, _) in source.match_indices("secrets.") { | |
| 367 | + | let name: String = source[at + "secrets.".len()..].chars().take_while(|c| c.is_ascii_alphanumeric() || *c == '_').collect(); | |
| 368 | + | let reserved = name.starts_with("G1T_") || name.starts_with("GITHUB_"); | |
| 369 | + | assert!(!reserved || name == "G1T_TOKEN" || name == "GITHUB_TOKEN", "{} reads secrets.{name}, a name g1t keeps for its own", path.display()); | |
| 370 | + | } | |
| 371 | + | } | |
| 372 | + | } | |