From 52e6249933762a281777a110ee7a4b3b135ff53d Mon Sep 17 00:00:00 2001 From: Nathan Heaps <1282393+nsheaps@users.noreply.github.com> Date: Mon, 23 Feb 2026 17:08:04 -0500 Subject: [PATCH 1/3] feat: add 1Password secret sync action Reusable composite action that syncs secrets from 1Password to GitHub repository secrets. Reads a YAML config defining source op:// URIs and target repos. Supports dry-run mode. Co-Authored-By: Claude Code (User Settings, in: /Users/nathan.heaps/src/nsheaps/agent-team) --- .../actions/1password-secret-sync/action.yml | 136 ++++++++++++++++++ README.md | 22 +++ 2 files changed, 158 insertions(+) create mode 100644 .github/actions/1password-secret-sync/action.yml diff --git a/.github/actions/1password-secret-sync/action.yml b/.github/actions/1password-secret-sync/action.yml new file mode 100644 index 0000000..2b3c288 --- /dev/null +++ b/.github/actions/1password-secret-sync/action.yml @@ -0,0 +1,136 @@ +name: "1Password Secret Sync" +description: "Sync secrets from 1Password to GitHub repository secrets" + +branding: + icon: "lock" + color: "purple" + +inputs: + config-file: + description: "Path to the secret sync config YAML file" + required: true + + op-service-account-token: + description: "1Password Service Account token" + required: true + + github-token: + description: "GitHub token with repo scope for setting secrets on target repos" + required: true + + dry-run: + description: "If true, validate config and read secrets but don't set them" + required: false + default: "false" + +outputs: + synced-count: + description: "Number of secrets successfully synced" + value: ${{ steps.sync.outputs.synced_count }} + + skipped-count: + description: "Number of secrets skipped (dry-run or errors)" + value: ${{ steps.sync.outputs.skipped_count }} + +runs: + using: "composite" + steps: + - name: Install 1Password CLI + uses: 1password/install-cli-action@v2 + + - name: Sync secrets + id: sync + shell: bash + env: + OP_SERVICE_ACCOUNT_TOKEN: ${{ inputs.op-service-account-token }} + GH_TOKEN: ${{ inputs.github-token }} + CONFIG_FILE: ${{ inputs.config-file }} + DRY_RUN: ${{ inputs.dry-run }} + run: | + set -euo pipefail + + synced=0 + skipped=0 + errors=0 + + echo "::group::Reading config from $CONFIG_FILE" + if [[ ! -f "$CONFIG_FILE" ]]; then + echo "::error::Config file not found: $CONFIG_FILE" + exit 1 + fi + + # Validate we can authenticate with 1Password + if ! op account list --format=json > /dev/null 2>&1; then + echo "::error::Failed to authenticate with 1Password. Check OP_SERVICE_ACCOUNT_TOKEN." + exit 1 + fi + echo "1Password authentication successful" + echo "::endgroup::" + + # Parse the config YAML using yq (installed below if missing) + if ! command -v yq &> /dev/null; then + echo "::group::Installing yq" + YQ_VERSION="v4.44.1" + curl -fsSL "https://github.com/mikefarah/yq/releases/download/${YQ_VERSION}/yq_linux_amd64" -o /usr/local/bin/yq + chmod +x /usr/local/bin/yq + echo "::endgroup::" + fi + + # Get total number of secret entries + total=$(yq '.secrets | length' "$CONFIG_FILE") + echo "Found $total secret definitions" + + for i in $(seq 0 $((total - 1))); do + name=$(yq ".secrets[$i].name" "$CONFIG_FILE") + source=$(yq ".secrets[$i].source" "$CONFIG_FILE") + target_count=$(yq ".secrets[$i].targets | length" "$CONFIG_FILE") + + echo "::group::Secret: $name (source: ${source:0:20}..., $target_count targets)" + + # Read secret from 1Password + value=$(op read "$source" 2>&1) || { + echo "::error::Failed to read secret '$name' from $source" + errors=$((errors + 1)) + echo "::endgroup::" + continue + } + + # Mask the value in logs + echo "::add-mask::$value" + echo "Successfully read secret '$name' from 1Password" + + # Sync to each target + for j in $(seq 0 $((target_count - 1))); do + target_repo=$(yq ".secrets[$i].targets[$j].repo" "$CONFIG_FILE") + target_name=$(yq ".secrets[$i].targets[$j].name // \"$name\"" "$CONFIG_FILE") + + if [[ "$DRY_RUN" == "true" ]]; then + echo "[DRY RUN] Would set '$target_name' on $target_repo" + skipped=$((skipped + 1)) + else + if echo "$value" | gh secret set "$target_name" --repo "$target_repo" --body -; then + echo "Set '$target_name' on $target_repo" + synced=$((synced + 1)) + else + echo "::warning::Failed to set '$target_name' on $target_repo" + errors=$((errors + 1)) + fi + fi + done + + echo "::endgroup::" + done + + echo "" + echo "=== Summary ===" + echo "Synced: $synced" + echo "Skipped: $skipped" + echo "Errors: $errors" + + echo "synced_count=$synced" >> "$GITHUB_OUTPUT" + echo "skipped_count=$skipped" >> "$GITHUB_OUTPUT" + + if [[ $errors -gt 0 ]]; then + echo "::error::$errors secret(s) failed to sync" + exit 1 + fi diff --git a/README.md b/README.md index a68d2e6..4de5813 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,28 @@ Deploy Docker Compose stacks to [Arcane](https://github.com/getarcaneapp/arcane) git-token: ${{ secrets.REPO_TOKEN }} ``` +### Secret Management Actions + +#### `1password-secret-sync` + +Sync secrets from 1Password to GitHub repository secrets. Reads a YAML config defining source `op://` URIs and target repos. Supports dry-run mode for validation. + +```yaml +- name: Sync 1Password secrets to GitHub + uses: nsheaps/github-actions/.github/actions/1password-secret-sync@main + with: + config-file: .github/secret-sync.yaml + op-service-account-token: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }} + github-token: ${{ secrets.SECRET_SYNC_PAT }} + dry-run: "false" +``` + +**Outputs:** + +- `synced-count` - Number of secrets successfully synced +- `skipped-count` - Number of secrets skipped (dry-run or errors) + + ### Security Linter Actions All security linters are designed to run in parallel for comprehensive security scanning. From 6e213e720bef055eda2305bba8d78bc7f4e0a622 Mon Sep 17 00:00:00 2001 From: Nathan Heaps <1282393+nsheaps@users.noreply.github.com> Date: Mon, 23 Feb 2026 21:57:33 -0500 Subject: [PATCH 2/3] fix(1password-sync): remove 2>&1 from op read, add yq checksum verification - Remove `2>&1` from `op read` to prevent error messages being written as secret values (P1 security fix) - Add SHA-256 checksum verification for yq binary download (P2 supply chain) - Install yq to $RUNNER_TEMP instead of /usr/local/bin (P3 permissions) - Move ::add-mask:: comment to clarify immediate masking after read Co-Authored-By: Claude Code (User Settings, in: /Users/nathan.heaps/src/nsheaps/agent-team) --- .github/actions/1password-secret-sync/action.yml | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/.github/actions/1password-secret-sync/action.yml b/.github/actions/1password-secret-sync/action.yml index 2b3c288..a2a97cb 100644 --- a/.github/actions/1password-secret-sync/action.yml +++ b/.github/actions/1password-secret-sync/action.yml @@ -71,8 +71,13 @@ runs: if ! command -v yq &> /dev/null; then echo "::group::Installing yq" YQ_VERSION="v4.44.1" - curl -fsSL "https://github.com/mikefarah/yq/releases/download/${YQ_VERSION}/yq_linux_amd64" -o /usr/local/bin/yq - chmod +x /usr/local/bin/yq + YQ_SHA256="6dc2d0cd4e0caca5aeffd0d784a48263591080e4a0895abe69f3a76eb50d1ba3" + YQ_INSTALL_DIR="${RUNNER_TEMP:-/tmp}/yq-bin" + mkdir -p "$YQ_INSTALL_DIR" + curl -fsSL "https://github.com/mikefarah/yq/releases/download/${YQ_VERSION}/yq_linux_amd64" -o "$YQ_INSTALL_DIR/yq" + echo "$YQ_SHA256 $YQ_INSTALL_DIR/yq" | sha256sum -c + chmod +x "$YQ_INSTALL_DIR/yq" + export PATH="$YQ_INSTALL_DIR:$PATH" echo "::endgroup::" fi @@ -88,14 +93,14 @@ runs: echo "::group::Secret: $name (source: ${source:0:20}..., $target_count targets)" # Read secret from 1Password - value=$(op read "$source" 2>&1) || { + value=$(op read "$source") || { echo "::error::Failed to read secret '$name' from $source" errors=$((errors + 1)) echo "::endgroup::" continue } - # Mask the value in logs + # Mask the value in logs immediately after reading echo "::add-mask::$value" echo "Successfully read secret '$name' from 1Password" From 8a632208275c999bbc0b9a14afa126b3ebd6cffd Mon Sep 17 00:00:00 2001 From: nsheaps <1282393+nsheaps@users.noreply.github.com> Date: Tue, 24 Feb 2026 03:25:44 +0000 Subject: [PATCH 3/3] chore: `mise format` Triggered by: 5135f10948f79bc3648af5918d3ce2fcaae5757e Workflow run: https://github.com/nsheaps/github-actions/actions/runs/22335278937 --- .../actions/1password-secret-sync/action.yml | 22 +++++++++---------- README.md | 3 +-- 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/.github/actions/1password-secret-sync/action.yml b/.github/actions/1password-secret-sync/action.yml index a2a97cb..67fcdcc 100644 --- a/.github/actions/1password-secret-sync/action.yml +++ b/.github/actions/1password-secret-sync/action.yml @@ -1,39 +1,39 @@ -name: "1Password Secret Sync" -description: "Sync secrets from 1Password to GitHub repository secrets" +name: '1Password Secret Sync' +description: 'Sync secrets from 1Password to GitHub repository secrets' branding: - icon: "lock" - color: "purple" + icon: 'lock' + color: 'purple' inputs: config-file: - description: "Path to the secret sync config YAML file" + description: 'Path to the secret sync config YAML file' required: true op-service-account-token: - description: "1Password Service Account token" + description: '1Password Service Account token' required: true github-token: - description: "GitHub token with repo scope for setting secrets on target repos" + description: 'GitHub token with repo scope for setting secrets on target repos' required: true dry-run: description: "If true, validate config and read secrets but don't set them" required: false - default: "false" + default: 'false' outputs: synced-count: - description: "Number of secrets successfully synced" + description: 'Number of secrets successfully synced' value: ${{ steps.sync.outputs.synced_count }} skipped-count: - description: "Number of secrets skipped (dry-run or errors)" + description: 'Number of secrets skipped (dry-run or errors)' value: ${{ steps.sync.outputs.skipped_count }} runs: - using: "composite" + using: 'composite' steps: - name: Install 1Password CLI uses: 1password/install-cli-action@v2 diff --git a/README.md b/README.md index 4de5813..85a492c 100644 --- a/README.md +++ b/README.md @@ -119,7 +119,7 @@ Sync secrets from 1Password to GitHub repository secrets. Reads a YAML config de config-file: .github/secret-sync.yaml op-service-account-token: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }} github-token: ${{ secrets.SECRET_SYNC_PAT }} - dry-run: "false" + dry-run: 'false' ``` **Outputs:** @@ -127,7 +127,6 @@ Sync secrets from 1Password to GitHub repository secrets. Reads a YAML config de - `synced-count` - Number of secrets successfully synced - `skipped-count` - Number of secrets skipped (dry-run or errors) - ### Security Linter Actions All security linters are designed to run in parallel for comprehensive security scanning.