From d5efecdbb58cb9dea0cdf8a8a57b06a3432d0531 Mon Sep 17 00:00:00 2001 From: Hweinstock Date: Tue, 22 Sep 2026 18:14:32 +0000 Subject: [PATCH 1/6] fix: support Windows in fetch secrets action --- .github/actions/fetch-secrets/action.yml | 43 +++------- .../actions/fetch-secrets/build-secret-ids.js | 63 ++++++++++++++ .../fetch-secrets/build-secret-ids.test.js | 84 +++++++++++++++++++ .github/workflows/fetch-secrets-tests.yml | 31 +++++++ 4 files changed, 191 insertions(+), 30 deletions(-) create mode 100644 .github/actions/fetch-secrets/build-secret-ids.js create mode 100644 .github/actions/fetch-secrets/build-secret-ids.test.js create mode 100644 .github/workflows/fetch-secrets-tests.yml diff --git a/.github/actions/fetch-secrets/action.yml b/.github/actions/fetch-secrets/action.yml index ec75cfe..6139fae 100644 --- a/.github/actions/fetch-secrets/action.yml +++ b/.github/actions/fetch-secrets/action.yml @@ -32,8 +32,14 @@ runs: using: composite steps: - name: Register role ARN mask - shell: bash - run: echo "::add-mask::${{ inputs.role-arn }}" + shell: node {0} + env: + ACTION_PATH: ${{ github.action_path }} + ROLE_ARN: ${{ inputs.role-arn }} + run: | + const path = require("node:path"); + const { registerMask } = require(path.join(process.env.ACTION_PATH, "build-secret-ids.js")); + registerMask(process.env.ROLE_ARN); - name: Configure AWS credentials (central secrets reader) uses: aws-actions/configure-aws-credentials@v6 @@ -44,39 +50,16 @@ runs: - name: Build secret-ids list id: build - shell: bash + shell: node {0} env: + ACTION_PATH: ${{ github.action_path }} SHARED_NAMES: ${{ inputs.shared }} REPO_NAMES: ${{ inputs.repo }} CALLER_REPO: ${{ github.repository }} run: | - set -euo pipefail - # Normalize commas/spaces to newlines, drop blanks; build ",/". - # Alias = the bare NAME, so each secret lands in an env var of that exact name. - : > /tmp/secret-ids.txt - emit() { - local prefix="$1" names="$2" - echo "$names" | tr ', ' '\n\n' | while IFS= read -r n; do - n="$(echo "$n" | xargs)" # trim surrounding whitespace - [ -z "$n" ] && continue - echo "${n},${prefix}/${n}" >> /tmp/secret-ids.txt - done - } - emit "shared" "$SHARED_NAMES" - emit "$CALLER_REPO" "$REPO_NAMES" - - if [ ! -s /tmp/secret-ids.txt ]; then - echo "::error::fetch-secrets: neither 'shared' nor 'repo' provided any secret names." - exit 1 - fi - - echo "Resolved secret-ids:" - sed 's/^/ /' /tmp/secret-ids.txt - { - echo "ids<> "$GITHUB_OUTPUT" + const path = require("node:path"); + const { main } = require(path.join(process.env.ACTION_PATH, "build-secret-ids.js")); + main(); - name: Fetch secrets uses: aws-actions/aws-secretsmanager-get-secrets@v2 diff --git a/.github/actions/fetch-secrets/build-secret-ids.js b/.github/actions/fetch-secrets/build-secret-ids.js new file mode 100644 index 0000000..42badf7 --- /dev/null +++ b/.github/actions/fetch-secrets/build-secret-ids.js @@ -0,0 +1,63 @@ +const fs = require("node:fs"); + +const OUTPUT_DELIMITER = "SECRET_IDS_EOF"; + +function parseNames(names = "") { + return names + .split(/[,\s]+/) + .map((name) => name.trim()) + .filter(Boolean); +} + +function buildSecretIds(sharedNames, repoNames, callerRepo) { + const formatIds = (names, prefix) => + parseNames(names).map((name) => `${name},${prefix}/${name}`); + + return [ + ...formatIds(sharedNames, "shared"), + ...formatIds(repoNames, callerRepo), + ]; +} + +function maskCommand(value) { + return `::add-mask::${value}`; +} + +function registerMask(value) { + console.log(maskCommand(value)); +} + +function main(environment = process.env) { + const secretIds = buildSecretIds( + environment.SHARED_NAMES, + environment.REPO_NAMES, + environment.CALLER_REPO, + ); + + if (secretIds.length === 0) { + console.error( + "::error::fetch-secrets: neither 'shared' nor 'repo' provided any secret names.", + ); + process.exitCode = 1; + return; + } + + console.log(`Resolved secret-ids:\n${secretIds.map((id) => ` ${id}`).join("\n")}`); + + fs.appendFileSync( + environment.GITHUB_OUTPUT, + `ids<<${OUTPUT_DELIMITER}\n${secretIds.join("\n")}\n${OUTPUT_DELIMITER}\n`, + ); +} + +if (require.main === module) { + main(); +} + +module.exports = { + buildSecretIds, + main, + maskCommand, + parseNames, + registerMask, +}; diff --git a/.github/actions/fetch-secrets/build-secret-ids.test.js b/.github/actions/fetch-secrets/build-secret-ids.test.js new file mode 100644 index 0000000..65c0e3e --- /dev/null +++ b/.github/actions/fetch-secrets/build-secret-ids.test.js @@ -0,0 +1,84 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const test = require("node:test"); + +const { + buildSecretIds, + main, + maskCommand, +} = require("./build-secret-ids"); + +test("builds shared and repo-specific secret IDs", () => { + assert.deepEqual( + buildSecretIds( + "SHARED_ONE\r\nSHARED_TWO", + "REPO_ONE, REPO_TWO", + "aws/example", + ), + [ + "SHARED_ONE,shared/SHARED_ONE", + "SHARED_TWO,shared/SHARED_TWO", + "REPO_ONE,aws/example/REPO_ONE", + "REPO_TWO,aws/example/REPO_TWO", + ], + ); +}); + +test("writes the secret IDs to the GitHub output file", () => { + const tempDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "fetch-secrets-")); + const outputPath = path.join(tempDirectory, "github-output"); + + try { + main({ + CALLER_REPO: "aws/example", + GITHUB_OUTPUT: outputPath, + REPO_NAMES: "REPO_ONE", + SHARED_NAMES: "SHARED_ONE", + }); + + assert.equal( + fs.readFileSync(outputPath, "utf8"), + [ + "ids< { + const previousExitCode = process.exitCode; + process.exitCode = undefined; + + try { + main({ + CALLER_REPO: "aws/example", + REPO_NAMES: "", + SHARED_NAMES: "", + }); + assert.equal(process.exitCode, 1); + } finally { + process.exitCode = previousExitCode; + } +}); + +test("formats the role ARN masking command", () => { + assert.equal( + maskCommand("arn:aws:iam::123456789012:role/example"), + "::add-mask::arn:aws:iam::123456789012:role/example", + ); +}); + +test("uses Node for every run step in the composite action", () => { + const action = fs.readFileSync(path.join(__dirname, "action.yml"), "utf8"); + + assert.doesNotMatch(action, /shell:\s+bash/); + assert.equal(action.match(/shell:\s+node \{0\}/g)?.length, 2); +}); diff --git a/.github/workflows/fetch-secrets-tests.yml b/.github/workflows/fetch-secrets-tests.yml new file mode 100644 index 0000000..373a398 --- /dev/null +++ b/.github/workflows/fetch-secrets-tests.yml @@ -0,0 +1,31 @@ +name: Fetch secrets tests + +on: + pull_request: + paths: + - .github/actions/fetch-secrets/** + - .github/workflows/fetch-secrets-tests.yml + push: + branches: + - main + paths: + - .github/actions/fetch-secrets/** + - .github/workflows/fetch-secrets-tests.yml + +permissions: {} + +jobs: + test: + name: Node tests (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: + - ubuntu-latest + - windows-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: 20 + - run: node --test .github/actions/fetch-secrets/build-secret-ids.test.js From 89323d9ca16639da033746350aab3eb44b962656 Mon Sep 17 00:00:00 2001 From: Hweinstock Date: Tue, 22 Sep 2026 18:22:00 +0000 Subject: [PATCH 2/6] chore: remove fetch secrets tests --- .../actions/fetch-secrets/build-secret-ids.js | 7 +- .../fetch-secrets/build-secret-ids.test.js | 84 ------------------- .github/workflows/fetch-secrets-tests.yml | 31 ------- 3 files changed, 1 insertion(+), 121 deletions(-) delete mode 100644 .github/actions/fetch-secrets/build-secret-ids.test.js delete mode 100644 .github/workflows/fetch-secrets-tests.yml diff --git a/.github/actions/fetch-secrets/build-secret-ids.js b/.github/actions/fetch-secrets/build-secret-ids.js index 42badf7..ef7b468 100644 --- a/.github/actions/fetch-secrets/build-secret-ids.js +++ b/.github/actions/fetch-secrets/build-secret-ids.js @@ -19,12 +19,8 @@ function buildSecretIds(sharedNames, repoNames, callerRepo) { ]; } -function maskCommand(value) { - return `::add-mask::${value}`; -} - function registerMask(value) { - console.log(maskCommand(value)); + console.log(`::add-mask::${value}`); } function main(environment = process.env) { @@ -57,7 +53,6 @@ if (require.main === module) { module.exports = { buildSecretIds, main, - maskCommand, parseNames, registerMask, }; diff --git a/.github/actions/fetch-secrets/build-secret-ids.test.js b/.github/actions/fetch-secrets/build-secret-ids.test.js deleted file mode 100644 index 65c0e3e..0000000 --- a/.github/actions/fetch-secrets/build-secret-ids.test.js +++ /dev/null @@ -1,84 +0,0 @@ -const assert = require("node:assert/strict"); -const fs = require("node:fs"); -const os = require("node:os"); -const path = require("node:path"); -const test = require("node:test"); - -const { - buildSecretIds, - main, - maskCommand, -} = require("./build-secret-ids"); - -test("builds shared and repo-specific secret IDs", () => { - assert.deepEqual( - buildSecretIds( - "SHARED_ONE\r\nSHARED_TWO", - "REPO_ONE, REPO_TWO", - "aws/example", - ), - [ - "SHARED_ONE,shared/SHARED_ONE", - "SHARED_TWO,shared/SHARED_TWO", - "REPO_ONE,aws/example/REPO_ONE", - "REPO_TWO,aws/example/REPO_TWO", - ], - ); -}); - -test("writes the secret IDs to the GitHub output file", () => { - const tempDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "fetch-secrets-")); - const outputPath = path.join(tempDirectory, "github-output"); - - try { - main({ - CALLER_REPO: "aws/example", - GITHUB_OUTPUT: outputPath, - REPO_NAMES: "REPO_ONE", - SHARED_NAMES: "SHARED_ONE", - }); - - assert.equal( - fs.readFileSync(outputPath, "utf8"), - [ - "ids< { - const previousExitCode = process.exitCode; - process.exitCode = undefined; - - try { - main({ - CALLER_REPO: "aws/example", - REPO_NAMES: "", - SHARED_NAMES: "", - }); - assert.equal(process.exitCode, 1); - } finally { - process.exitCode = previousExitCode; - } -}); - -test("formats the role ARN masking command", () => { - assert.equal( - maskCommand("arn:aws:iam::123456789012:role/example"), - "::add-mask::arn:aws:iam::123456789012:role/example", - ); -}); - -test("uses Node for every run step in the composite action", () => { - const action = fs.readFileSync(path.join(__dirname, "action.yml"), "utf8"); - - assert.doesNotMatch(action, /shell:\s+bash/); - assert.equal(action.match(/shell:\s+node \{0\}/g)?.length, 2); -}); diff --git a/.github/workflows/fetch-secrets-tests.yml b/.github/workflows/fetch-secrets-tests.yml deleted file mode 100644 index 373a398..0000000 --- a/.github/workflows/fetch-secrets-tests.yml +++ /dev/null @@ -1,31 +0,0 @@ -name: Fetch secrets tests - -on: - pull_request: - paths: - - .github/actions/fetch-secrets/** - - .github/workflows/fetch-secrets-tests.yml - push: - branches: - - main - paths: - - .github/actions/fetch-secrets/** - - .github/workflows/fetch-secrets-tests.yml - -permissions: {} - -jobs: - test: - name: Node tests (${{ matrix.os }}) - runs-on: ${{ matrix.os }} - strategy: - matrix: - os: - - ubuntu-latest - - windows-latest - steps: - - uses: actions/checkout@v6 - - uses: actions/setup-node@v6 - with: - node-version: 20 - - run: node --test .github/actions/fetch-secrets/build-secret-ids.test.js From 8a6fb01d481c6ed07162ea1709baa1a03c513be0 Mon Sep 17 00:00:00 2001 From: Hweinstock Date: Tue, 22 Sep 2026 18:23:56 +0000 Subject: [PATCH 3/6] docs: document fetch secrets script --- .../actions/fetch-secrets/build-secret-ids.js | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/.github/actions/fetch-secrets/build-secret-ids.js b/.github/actions/fetch-secrets/build-secret-ids.js index ef7b468..e723b80 100644 --- a/.github/actions/fetch-secrets/build-secret-ids.js +++ b/.github/actions/fetch-secrets/build-secret-ids.js @@ -2,6 +2,12 @@ const fs = require("node:fs"); const OUTPUT_DELIMITER = "SECRET_IDS_EOF"; +/** + * Parses comma- or whitespace-separated secret names. + * + * @param names - Raw action input. + * @returns The non-empty secret names in input order. + */ function parseNames(names = "") { return names .split(/[,\s]+/) @@ -9,6 +15,15 @@ function parseNames(names = "") { .filter(Boolean); } +/** + * Formats aliases and secret paths for the Secrets Manager action. + * + * @param sharedNames - Names resolved below the shared prefix. + * @param repoNames - Names resolved below the calling repository prefix. + * @param callerRepo - Calling repository in owner/name form. + * @returns Alias and secret ID pairs. + * @see {@link https://github.com/aws-actions/aws-secretsmanager-get-secrets} + */ function buildSecretIds(sharedNames, repoNames, callerRepo) { const formatIds = (names, prefix) => parseNames(names).map((name) => `${name},${prefix}/${name}`); @@ -19,10 +34,22 @@ function buildSecretIds(sharedNames, repoNames, callerRepo) { ]; } +/** + * Registers a value for redaction from subsequent GitHub Actions logs. + * + * @param value - Sensitive value to mask. + * @see {@link https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#masking-a-value-in-a-log} + */ function registerMask(value) { console.log(`::add-mask::${value}`); } +/** + * Resolves action inputs and appends the multiline secret ID step output. + * + * @param environment - GitHub Actions environment variables. + * @see {@link https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#setting-an-output-parameter} + */ function main(environment = process.env) { const secretIds = buildSecretIds( environment.SHARED_NAMES, From 8a2e97f3ea96481004eae6ed1478e3c1836236ef Mon Sep 17 00:00:00 2001 From: Hweinstock Date: Tue, 22 Sep 2026 18:39:39 +0000 Subject: [PATCH 4/6] refactor: simplify fetch secrets exports --- .../actions/fetch-secrets/build-secret-ids.js | 58 ++++++------------- 1 file changed, 19 insertions(+), 39 deletions(-) diff --git a/.github/actions/fetch-secrets/build-secret-ids.js b/.github/actions/fetch-secrets/build-secret-ids.js index e723b80..b4ef709 100644 --- a/.github/actions/fetch-secrets/build-secret-ids.js +++ b/.github/actions/fetch-secrets/build-secret-ids.js @@ -2,38 +2,6 @@ const fs = require("node:fs"); const OUTPUT_DELIMITER = "SECRET_IDS_EOF"; -/** - * Parses comma- or whitespace-separated secret names. - * - * @param names - Raw action input. - * @returns The non-empty secret names in input order. - */ -function parseNames(names = "") { - return names - .split(/[,\s]+/) - .map((name) => name.trim()) - .filter(Boolean); -} - -/** - * Formats aliases and secret paths for the Secrets Manager action. - * - * @param sharedNames - Names resolved below the shared prefix. - * @param repoNames - Names resolved below the calling repository prefix. - * @param callerRepo - Calling repository in owner/name form. - * @returns Alias and secret ID pairs. - * @see {@link https://github.com/aws-actions/aws-secretsmanager-get-secrets} - */ -function buildSecretIds(sharedNames, repoNames, callerRepo) { - const formatIds = (names, prefix) => - parseNames(names).map((name) => `${name},${prefix}/${name}`); - - return [ - ...formatIds(sharedNames, "shared"), - ...formatIds(repoNames, callerRepo), - ]; -} - /** * Registers a value for redaction from subsequent GitHub Actions logs. * @@ -48,14 +16,28 @@ function registerMask(value) { * Resolves action inputs and appends the multiline secret ID step output. * * @param environment - GitHub Actions environment variables. + * @see {@link https://github.com/aws-actions/aws-secretsmanager-get-secrets} * @see {@link https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#setting-an-output-parameter} */ function main(environment = process.env) { - const secretIds = buildSecretIds( - environment.SHARED_NAMES, - environment.REPO_NAMES, - environment.CALLER_REPO, - ); + /** + * Formats one input list as alias and secret path pairs. + * + * @param names - Comma- or whitespace-separated secret names. + * @param prefix - Secrets Manager path prefix. + * @returns Alias and secret ID pairs. + */ + const formatIds = (names = "", prefix) => + names + .split(/[,\s]+/) + .map((name) => name.trim()) + .filter(Boolean) + .map((name) => `${name},${prefix}/${name}`); + + const secretIds = [ + ...formatIds(environment.SHARED_NAMES, "shared"), + ...formatIds(environment.REPO_NAMES, environment.CALLER_REPO), + ]; if (secretIds.length === 0) { console.error( @@ -78,8 +60,6 @@ if (require.main === module) { } module.exports = { - buildSecretIds, main, - parseNames, registerMask, }; From c2c016d8e4bfe8a7aef96795134e77441e2432c5 Mon Sep 17 00:00:00 2001 From: Hweinstock Date: Tue, 22 Sep 2026 18:42:06 +0000 Subject: [PATCH 5/6] docs: simplify formatter comment --- .github/actions/fetch-secrets/build-secret-ids.js | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/.github/actions/fetch-secrets/build-secret-ids.js b/.github/actions/fetch-secrets/build-secret-ids.js index b4ef709..2d6c648 100644 --- a/.github/actions/fetch-secrets/build-secret-ids.js +++ b/.github/actions/fetch-secrets/build-secret-ids.js @@ -20,13 +20,7 @@ function registerMask(value) { * @see {@link https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#setting-an-output-parameter} */ function main(environment = process.env) { - /** - * Formats one input list as alias and secret path pairs. - * - * @param names - Comma- or whitespace-separated secret names. - * @param prefix - Secrets Manager path prefix. - * @returns Alias and secret ID pairs. - */ + // Format each name as ",/". const formatIds = (names = "", prefix) => names .split(/[,\s]+/) From 6d939d4cd26df510faef69a50eaf0cd3cd561657 Mon Sep 17 00:00:00 2001 From: Hweinstock Date: Tue, 22 Sep 2026 18:46:17 +0000 Subject: [PATCH 6/6] refactor: clarify secret mapping names --- .github/actions/fetch-secrets/action.yml | 8 ++++---- ...-secret-ids.js => build-secret-mappings.js} | 18 ++++++++++-------- 2 files changed, 14 insertions(+), 12 deletions(-) rename .github/actions/fetch-secrets/{build-secret-ids.js => build-secret-mappings.js} (71%) diff --git a/.github/actions/fetch-secrets/action.yml b/.github/actions/fetch-secrets/action.yml index 6139fae..fb3af68 100644 --- a/.github/actions/fetch-secrets/action.yml +++ b/.github/actions/fetch-secrets/action.yml @@ -38,7 +38,7 @@ runs: ROLE_ARN: ${{ inputs.role-arn }} run: | const path = require("node:path"); - const { registerMask } = require(path.join(process.env.ACTION_PATH, "build-secret-ids.js")); + const { registerMask } = require(path.join(process.env.ACTION_PATH, "build-secret-mappings.js")); registerMask(process.env.ROLE_ARN); - name: Configure AWS credentials (central secrets reader) @@ -48,7 +48,7 @@ runs: aws-region: ${{ inputs.aws-region }} mask-aws-account-id: true - - name: Build secret-ids list + - name: Build secret mappings id: build shell: node {0} env: @@ -58,11 +58,11 @@ runs: CALLER_REPO: ${{ github.repository }} run: | const path = require("node:path"); - const { main } = require(path.join(process.env.ACTION_PATH, "build-secret-ids.js")); + const { main } = require(path.join(process.env.ACTION_PATH, "build-secret-mappings.js")); main(); - name: Fetch secrets uses: aws-actions/aws-secretsmanager-get-secrets@v2 with: - secret-ids: ${{ steps.build.outputs.ids }} + secret-ids: ${{ steps.build.outputs.mappings }} parse-json-secrets: false diff --git a/.github/actions/fetch-secrets/build-secret-ids.js b/.github/actions/fetch-secrets/build-secret-mappings.js similarity index 71% rename from .github/actions/fetch-secrets/build-secret-ids.js rename to .github/actions/fetch-secrets/build-secret-mappings.js index 2d6c648..23fc63c 100644 --- a/.github/actions/fetch-secrets/build-secret-ids.js +++ b/.github/actions/fetch-secrets/build-secret-mappings.js @@ -1,6 +1,6 @@ const fs = require("node:fs"); -const OUTPUT_DELIMITER = "SECRET_IDS_EOF"; +const OUTPUT_DELIMITER = "SECRET_MAPPINGS_EOF"; /** * Registers a value for redaction from subsequent GitHub Actions logs. @@ -21,19 +21,19 @@ function registerMask(value) { */ function main(environment = process.env) { // Format each name as ",/". - const formatIds = (names = "", prefix) => + const formatMappings = (names = "", prefix) => names .split(/[,\s]+/) .map((name) => name.trim()) .filter(Boolean) .map((name) => `${name},${prefix}/${name}`); - const secretIds = [ - ...formatIds(environment.SHARED_NAMES, "shared"), - ...formatIds(environment.REPO_NAMES, environment.CALLER_REPO), + const secretMappings = [ + ...formatMappings(environment.SHARED_NAMES, "shared"), + ...formatMappings(environment.REPO_NAMES, environment.CALLER_REPO), ]; - if (secretIds.length === 0) { + if (secretMappings.length === 0) { console.error( "::error::fetch-secrets: neither 'shared' nor 'repo' provided any secret names.", ); @@ -41,11 +41,13 @@ function main(environment = process.env) { return; } - console.log(`Resolved secret-ids:\n${secretIds.map((id) => ` ${id}`).join("\n")}`); + console.log( + `Resolved secret mappings:\n${secretMappings.map((mapping) => ` ${mapping}`).join("\n")}`, + ); fs.appendFileSync( environment.GITHUB_OUTPUT, - `ids<<${OUTPUT_DELIMITER}\n${secretIds.join("\n")}\n${OUTPUT_DELIMITER}\n`, + `mappings<<${OUTPUT_DELIMITER}\n${secretMappings.join("\n")}\n${OUTPUT_DELIMITER}\n`, ); }