diff --git a/.github/actions/1password-secret-sync/action.yml b/.github/actions/1password-secret-sync/action.yml new file mode 100644 index 0000000..67fcdcc --- /dev/null +++ b/.github/actions/1password-secret-sync/action.yml @@ -0,0 +1,141 @@ +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" + 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 + + # 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") || { + echo "::error::Failed to read secret '$name' from $source" + errors=$((errors + 1)) + echo "::endgroup::" + continue + } + + # Mask the value in logs immediately after reading + 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..85a492c 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,27 @@ 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.