diff --git a/.gitea/workflows/release-tool.yml b/.gitea/workflows/release-tool.yml index ffd9567..72322a5 100644 --- a/.gitea/workflows/release-tool.yml +++ b/.gitea/workflows/release-tool.yml @@ -16,6 +16,7 @@ jobs: - name: Sync tools checkout to tag env: TAG_NAME: ${{ github.ref_name }} + EVENT_SHA: ${{ github.sha }} GITEA_SERVER_URL: ${{ github.server_url }} GITEA_REPO: ${{ github.repository }} run: | @@ -34,18 +35,27 @@ jobs: cd "${CHECKOUT_PATH}" git remote set-url origin "${SOW_TOOLS_REPO_URL}" echo "Fetching tools refs from ${SOW_TOOLS_REPO_URL}" - timeout 120 git fetch origin --prune --tags + timeout 120 git fetch origin --prune --force --tags + TAG_SHA="$(git rev-list -n1 "refs/tags/${TAG_NAME}")" + if [[ -n "${EVENT_SHA}" && "${TAG_SHA}" != "${EVENT_SHA}" ]]; then + echo "Fetched tag ${TAG_NAME} resolves to ${TAG_SHA}, but the workflow event SHA is ${EVENT_SHA}." + echo "Refusing to build a release from a stale or mismatched local tag." + exit 1 + fi git reset --hard "refs/tags/${TAG_NAME}" - git clean -fd + git clean -ffdx + echo "Release ${TAG_NAME} will build commit $(git rev-parse HEAD)" - name: Build sow-toolkit binaries run: | set -euo pipefail CHECKOUT_PATH="${CHECKOUT_PATH:-/home/ubuntu/tools-checkout}" cd "${CHECKOUT_PATH}" + rm -f ./tools/sow-toolkit ./tools/sow-toolkit.exe mkdir -p tools .cache/go-build GOCACHE="${PWD}/.cache/go-build" go build -o ./tools/sow-toolkit ./cmd/nwn-tool GOOS=windows GOARCH=amd64 GOCACHE="${PWD}/.cache/go-build" go build -o ./tools/sow-toolkit.exe ./cmd/nwn-tool + go version -m ./tools/sow-toolkit - name: Pack release bundles id: pack diff --git a/internal/topdata/native.go b/internal/topdata/native.go index a9bfdf6..5badd80 100644 --- a/internal/topdata/native.go +++ b/internal/topdata/native.go @@ -1396,8 +1396,10 @@ func newFeatGeneratedContext(dataset nativeDataset, lockData map[string]int) (*f if err != nil { return nil, err } - for key := range retiredFeatKeys { - delete(lockCopy, key) + for key, retiredID := range retiredFeatKeys { + if lockedID, ok := lockCopy[key]; ok && lockedID == retiredID { + delete(lockCopy, key) + } delete(existingFeat, key) } return &featGeneratedContext{ @@ -1466,7 +1468,7 @@ func (c *featGeneratedContext) featKeyExists(key string) bool { return ok } -func collectRetiredFeatKeys(featDir string) (map[string]struct{}, error) { +func collectRetiredFeatKeys(featDir string) (map[string]int, error) { baseObj, err := loadJSONObject(filepath.Join(featDir, "base.json")) if err != nil { return nil, err @@ -1501,7 +1503,7 @@ func collectRetiredFeatKeys(featDir string) (map[string]struct{}, error) { nextID = nextAvailableID(usedIDs) return rowID } - retired := map[string]struct{}{} + retired := map[string]int{} modulePaths, err := collectModulePaths(filepath.Join(featDir, "modules")) if err != nil { return nil, err @@ -1567,7 +1569,7 @@ func collectRetiredFeatKeys(featDir string) (map[string]struct{}, error) { currentKey := rowIDToKey[rowID] if overrideRequestsNullRow(override) { if currentKey != "" { - retired[currentKey] = struct{}{} + retired[currentKey] = rowID delete(rowKeyToID, currentKey) delete(rowIDToKey, rowID) } @@ -1576,7 +1578,7 @@ func collectRetiredFeatKeys(featDir string) (map[string]struct{}, error) { if rawKey, present := override["key"]; present { if rawKey == nil { if currentKey != "" { - retired[currentKey] = struct{}{} + retired[currentKey] = rowID delete(rowKeyToID, currentKey) delete(rowIDToKey, rowID) } @@ -1585,14 +1587,14 @@ func collectRetiredFeatKeys(featDir string) (map[string]struct{}, error) { newKey, ok := rawKey.(string) if !ok || newKey == "" || strings.TrimSpace(newKey) == nullValue { if currentKey != "" { - retired[currentKey] = struct{}{} + retired[currentKey] = rowID delete(rowKeyToID, currentKey) delete(rowIDToKey, rowID) } continue } if currentKey != "" && currentKey != newKey { - retired[currentKey] = struct{}{} + retired[currentKey] = rowID delete(rowKeyToID, currentKey) } if previousID, exists := rowKeyToID[newKey]; exists && previousID != rowID { @@ -4320,7 +4322,10 @@ func retireRowIdentity(row map[string]any, lockData map[string]int, retiredKeys if !ok || oldKey == "" { return } - delete(lockData, oldKey) + rowID, _ := row["id"].(int) + if lockedID, ok := lockData[oldKey]; ok && lockedID == rowID { + delete(lockData, oldKey) + } if retiredKeys != nil { retiredKeys[oldKey] = struct{}{} } @@ -4348,7 +4353,9 @@ func updateOverrideRowKey(datasetName string, row map[string]any, expanded map[s delete(row, "key") if oldKey != "" { retiredKey = oldKey - delete(lockData, oldKey) + if lockedID, ok := lockData[oldKey]; ok && lockedID == rowID { + delete(lockData, oldKey) + } changed = true } return changed, retiredKey, nil @@ -4358,7 +4365,9 @@ func updateOverrideRowKey(datasetName string, row map[string]any, expanded map[s delete(row, "key") if oldKey != "" { retiredKey = oldKey - delete(lockData, oldKey) + if lockedID, ok := lockData[oldKey]; ok && lockedID == rowID { + delete(lockData, oldKey) + } changed = true } return changed, retiredKey, nil @@ -4386,7 +4395,9 @@ func updateOverrideRowKey(datasetName string, row map[string]any, expanded map[s rowByKey[newKey] = row if oldKey != "" && oldKey != newKey { retiredKey = oldKey - delete(lockData, oldKey) + if lockedID, ok := lockData[oldKey]; ok && lockedID == rowID { + delete(lockData, oldKey) + } changed = true } if existingID, ok := lockData[newKey]; ok { diff --git a/internal/topdata/topdata_test.go b/internal/topdata/topdata_test.go index 468d1b6..b6464af 100644 --- a/internal/topdata/topdata_test.go +++ b/internal/topdata/topdata_test.go @@ -6526,6 +6526,53 @@ func TestUpdateOverrideRowKeyRetiresOldKeyEvenIfReferenced(t *testing.T) { } } +func TestUpdateOverrideRowKeyDoesNotDeleteRelocatedReplacementLock(t *testing.T) { + row := map[string]any{"id": 58, "key": "baseitems:shortspear"} + rowByKey := map[string]map[string]any{ + "baseitems:shortspear": row, + } + lockData := map[string]int{ + "baseitems:shortspear": 126, + } + expanded := map[string]any{ + "key": "baseitems:spear", + } + + changed, retiredKey, err := updateOverrideRowKey("baseitems", row, expanded, rowByKey, lockData) + if err != nil { + t.Fatalf("updateOverrideRowKey failed: %v", err) + } + if !changed { + t.Fatal("expected key reassignment to mark the row changed") + } + if retiredKey != "baseitems:shortspear" { + t.Fatalf("expected retired key baseitems:shortspear, got %q", retiredKey) + } + if lockData["baseitems:shortspear"] != 126 { + t.Fatalf("expected relocated replacement lock to survive old row retirement, got %#v", lockData) + } + if lockData["baseitems:spear"] != 58 { + t.Fatalf("expected new row key to lock to row 58, got %#v", lockData) + } +} + +func TestRetireRowIdentityDoesNotDeleteRelocatedReplacementLock(t *testing.T) { + row := map[string]any{"id": 916, "key": "feat:skill_focus_intimidate"} + lockData := map[string]int{ + "feat:skill_focus_intimidate": 1333, + } + retiredKeys := map[string]struct{}{} + + retireRowIdentity(row, lockData, retiredKeys) + + if lockData["feat:skill_focus_intimidate"] != 1333 { + t.Fatalf("expected replacement lock to survive nulled old row, got %#v", lockData) + } + if _, ok := retiredKeys["feat:skill_focus_intimidate"]; !ok { + t.Fatalf("expected old row key to be recorded as retired") + } +} + func TestBuildPrunesLockedIDThatTargetsManualBaseSpace(t *testing.T) { root := testProjectRoot(t) mkdirAll(t, filepath.Join(root, "topdata", "data", "dense", "modules"))